Skip to content

fix(lint,metadata-protocol): a junk entry in stack.objects no longer crashes the reference-integrity seam, and a throwing probe rule is reported (#15494) - #15562

Merged
baozhoutao merged 5 commits into
mainfrom
claude/issue-15494-object-graph-null-entry
Sep 4, 2026
Merged

fix(lint,metadata-protocol): a junk entry in stack.objects no longer crashes the reference-integrity seam, and a throwing probe rule is reported (#15494)#15562
baozhoutao merged 5 commits into
mainfrom
claude/issue-15494-object-graph-null-entry

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15494

indexObjectGraph is the first statement of every rule that resolves a field path, and its local asArray returned an array unchanged — so a null member of stack.objects reached strName(obj.name) and threw before any rule's own per-object guard could run. One guard at the shared seam, and the whole family inherits it.

The decision this card asked for: SKIPPED, not reported

A non-record entry is silently dropped. Three independent readings pointed the same way, and the third is a measurement rather than an argument:

  1. The siblings already answer it. Every asArray copy in @objectstack/lint that spells the defensive read at all drops the member and says nothing — validate-nav-target-refs.ts, validate-flow-node-writes.ts, validate-hook-body-writes.ts, validate-page-visualization-bindings.ts, validate-readonly-action-writes.ts, validate-rule-compilability.ts, validate-view-page-refs.ts and the rest. Not one emits a finding. The seam was the outlier, not the reporters.
  2. Each member of the family already answers it three lines below the call, with if (!isRec(obj)) continue in its own per-object loop. A report from the seam would contradict the guard the same rule is about to run.
  3. The raw lint path, measured. Driving the whole AUTHORING_RULES table (42 rules) over { objects: [null, validObject] }: 28 rules judge it in silence, 13 throw, and 0 report the junk entry. The one rule that reports anything is validateSecurityPosture with security-owd-unset, and its path moves from objects[1].sharingModel to objects[0].sharingModel when the junk entry is removed — a finding about the VALID object, not about the junk.

How the parsed path can produce one at all: it mostly cannot, and that is the point — these rules are pure (stack) to Finding[] (ADR-0019) and run on the raw path too, where objects is whatever the author's files deserialised to. An empty YAML list item is exactly null. A shape defect is the schema's subject, not reference integrity's, and object-graph.ts decides no severities and holds no rule ids by contract — reporting from there would also emit the same finding once per member for one bad entry.

Reproduction

On origin/main at 615fac3a0, before the change:

THROW validateObjectFieldRefs   { objects: [null] }            -> TypeError: Cannot read properties of null (reading 'name')
        at indexObjectGraph (packages/lint/src/object-graph.ts:159:30)
THROW validateObjectFieldRefs   { objects: [null, validObject] } -> TypeError: Cannot read properties of null (reading 'name')
        at indexObjectGraph (packages/lint/src/object-graph.ts:159:30)
THROW validateListViewFieldRefs { objects: [null] }            -> TypeError: Cannot read properties of null (reading 'name')
        at indexObjectGraph (packages/lint/src/object-graph.ts:159:30)

After, with the valid object beside it still judged:

OK validateObjectFieldRefs   { objects: [null] }              -> 0 finding(s)
OK validateObjectFieldRefs   { objects: [null, validObject] } -> 1 finding(s) [object-field-ref-unknown]
OK validateListViewFieldRefs { objects: [null, validObject] } -> 1 finding(s) [list-view-field-unknown]
OK validateDatasetReferences { objects: [null, validObject] } -> 1 finding(s) [dataset-field-unknown]

The reported positions are unchanged — objects[1].highlightFields[1], objects[1].listViews.all.columns[1], datasets[0].dimensions[0].field. The guard drops the entry from the GRAPH while each member's own loop keeps walking the raw array, so paths still count the junk entry and stay stable against the author's file.

What changed

  • packages/lint/src/object-graph.tsasArray filters non-records in both of its uses: stack.objects, and obj.fields one level down (fields: [null] crashed at the identical statement). The name-keyed map branch stops spreading a non-record value; the verdict for { a: 'junk' } is no-field-map before and after.
  • packages/metadata-protocol/src/build-probes.ts — the object plane's catch { findings = [] } becomes a runtime-layer object_field_ref_rule_failed error (severity error) carrying the thrown message. A rule that throws never again reads as zero findings on a receipt whose checked.objects already counted the object. Probes still never fail the publish they verify.
  • Tests: seam pins in object-graph.test.ts; one case per indexObjectGraph caller in the shared reference-integrity-suite.test.ts harness; build-probes-rule-failure.test.ts for the probe half. Changeset: @objectstack/lint patch + @objectstack/metadata-protocol patch.

Reverse verification

Each half was ablated from the committed state, with the mutation proved on disk (marker counts + blob equality against 615fac3a0) and the restore proved by blob hash and an empty git diff HEAD. Expected direction was red-on-revert for each half's own tests and no others; that is what happened.

ablation marker counts result
object-graph.ts guard reverted to the base form guard 1 to 0; base form present; mutated blob 35b0a44a equals base blob 35b0a44a object-graph.test.ts: 2 failed | 23 passedAssertionError: expected [Function] to not throw an error but 'TypeError: Cannot read properties of …' was thrown
build-probes.ts catch reverted to the base form marker 3 to 0; base form present; mutated blob 015f56c2 equals base blob 015f56c2 build-probes-rule-failure.test.ts: 2 failed | 2 passedAssertionError: expected [] to have a length of 1 but got +0

The second line is the defect in one sentence: an empty list where a crash happened.

One gate found a real problem, and then a phantom fix for it

check:error-code-casing red-flagged the new test file's object_field_ref_rule_failed literals (ADR-0112 D1 wants SCREAMING_SNAKE). The code is D6c — build-probe diagnostics shipped inside a 200 receipt, the same vocabulary the gate already exempts build-probes.ts and packages/objectql/src/build-probes.test.ts whole for — so the per-literal adr0112-ok: mark is the narrower spelling of that exemption, and no gate script or ledger was edited.

Worth recording: the marks were first written as a comment block above the literal, and that read as green while suppressing nothing — the extra lines moved the literal out of the gate's recognition window entirely, so deleting the mark left the gate green too. Falsifying each mark individually caught it. They are now on the literal's own line (the spelling the gate's own self-test pins), and removing either one reds the gate.

What this does NOT fix — filed as #15552, not ridden here

The card's premise holds for the seam it names, but it is narrower than the class. Measured after this change, 13 of 42 AUTHORING_RULES entries still throw on { objects: [null] }, through five more unguarded readers of stack.objects:

  • packages/lint/src/validate-widget-bindings.ts:465 — the fourth indexObjectGraph caller, which dereferences the entry in its aggregate-coherence pass BEFORE it reaches the seam, so this guard cannot reach it. That file is held by PR feat(lint): report a chart widget that selects no measures — or no dimensions (#15462) #15507, which is open and armed.
  • Three inside the reference-integrity suite itself — validate-object-references.ts:142, indexObjectSearchTargets (validate-searchable-fields.ts:287, two rules), indexObjectFields (validate-page-field-bindings.ts:358, two rules) — so validateReferenceIntegrity({ objects: [null] }) still throws.
  • filter-walk.ts:155 and six other single-rule readers.

⭐ That is stated in the suite test's own docblock as well, so the pin cannot be misread as covering the family entry point. The full inventory with per-rule throw sites and a control leg is in #15552, which is unassigned and is not addressed by this PR.

Verification

Exit codes captured before any pipe; verdict lines quoted as the tools printed them. Final commit for the gate re-run named below.

what result
pnpm --filter @objectstack/lint test Test Files 95 passed (95), Tests 2946 passed (2946), VERDICT command-exit 0
pnpm --filter @objectstack/lint typecheck exit 0 — check:test-typecheck: OK — @objectstack/lint's test layer compiles … 2 file(s) / 6 error(s) / 2 pinned signature(s) held (unchanged ledger)
@objectstack/metadata-protocol — 10 probe / publish-drafts suites Test Files 10 passed (10), Tests 57 passed (57), VERDICT command-exit 0
pnpm --filter @objectstack/metadata-protocol typecheck exit 0 (tsc --noEmit, clean)
packages/objectql src/build-probes.test.ts (the probes.checked pin) Test Files 1 passed (1), Tests 10 passed (10) — run after pnpm --filter @objectstack/lint --filter @objectstack/metadata-protocol build, so it read the new dist, not a stale one
derived family, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 48 commands: 46 exit 0, 2 exit 3
pnpm -s check:pm-dispatch-gates exit 0 — ✓ dispatch-gates self-test: 1402 cases pass.
pnpm check:nul-bytes exit 0 — ✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)
pnpm check:engine-double-contract exit 0 — check-engine-double-contract: OK — 781 pinned, 133 in the DEBT ledger, 3 exempt. (no --write needed)
pnpm check:objectql-double-limit exit 0 — OK ObjectQL double limit conformance holds: 325 double(s) graded, 126 apply the caller's bound or refuse it loudly.
pnpm check:error-code-casing exit 0 — ✓ no unlisted lowercase error codes in 5430 scanned file(s) (ADR-0112). (was exit 1; see above)
pnpm check:cross-package-test-inputs exit 0 — OK: 26 package(s) read outside themselves, all declared
pnpm check:test-source-alias exit 0 — check-test-source-alias OK — 72 packages with tests scanned
whole-repo pnpm lint (eslint . --no-inline-config) exit 0 — the full farm, not a narrowed run

The gate family was derived after merging origin/main — the first derivation carried a ⚠️ STALE TREE banner naming 3 files the families derive from as changed, so it was discarded rather than reported. The re-derivation on the merged tree reports --repo 'objectstack-ai/objectstack' checked against this checkout's 'origin' remote — it holds. and yields the identical 48-command family.

The two exit-3s are NOT MEASURED, and are not read as green:

  • pnpm check:dual-build-cjs-loadsRun pnpm build first. ⛔ This is NOT a pass: nothing was measured.
  • pnpm check:type-check-debt⛔ This is NOT a pass and NOT a finding: nothing was measured

Both need a full monorepo build closure, which CI performs.

Re-run on the final commit ba6dd6e80, after the last two commits moved the tree — the ratchet families in particular, whose counts a late commit is exactly what moves: check:nul-bytes (OK (scanned 7520 text file(s) … no raw ASCII control bytes)), check:engine-double-contract, check:objectql-double-limit, check:error-code-casing, check:cross-package-test-inputs, check:test-source-alias, check:query-options-erasure (ratchet holds … none new), check:slot-lookup (ratchet holds … none new), check:type-check-coverage, check:doc-authoring — all exit 0. The two package test runs and pnpm lint above were also taken at ba6dd6e80.

Declared narrowings (CI runs the full farm regardless):

  • One narrowing only: the full @objectstack/metadata-protocol suite (152 files) exceeds this container's 10-minute foreground cap under shared-lock contention. The 10 suites run above are its probe, publish-drafts and authoring-gate families — the ones this change can reach — plus the new file. CI runs the rest.
  • pnpm lint was not narrowed: the whole-repo run is above, exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…crashes the reference-integrity seam, and a throwing probe rule is reported

`indexObjectGraph` is the first statement of every rule that resolves a field
path, and its local `asArray` returned an array unchanged — so a `null` member
of `stack.objects` reached `strName(obj.name)` and threw
`TypeError: Cannot read properties of null (reading 'name')` before any
member's own `if (!isRec(obj)) continue` could run. These rules are pure
`(stack) => Finding[]` and run on the raw `lint` path as well as the parsed
one, and at the runtime publish gate they are called inside the gate: a throw
there is an exception on a write path, not a skipped finding.

The entry is SKIPPED, not reported. Every sibling `asArray` in this package
that spells the defensive read drops the member silently, each member of the
family already answers the same question three lines below the call, and this
module decides no severities by contract. Driving the whole `AUTHORING_RULES`
table over `{ objects: [null, validObject] }` measured 28 rules judging it in
silence and none reporting the junk entry.

Second half, on the receipt: `runBuildProbes`' object plane wrapped the rule
in `catch { findings = [] }`, so a crash produced the byte-identical receipt a
clean object produces while `checked.objects` had already counted it. It now
emits a `runtime`-layer `object_field_ref_rule_failed` error carrying the
thrown message. Probes still never fail the publish they verify.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…rotocol for the object-graph null-entry guard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…D6c on the literal's own line

check:error-code-casing reads the closed SCREAMING_SNAKE catalog; a build-probe
diagnostics code shipped inside a 200 receipt is D6c, which is why the gate
exempts build-probes.ts and the objectql probe test whole. The per-literal mark
is the narrower spelling of the same exemption.

Written on the literal's own line deliberately: a multi-line comment above it was
measured to move the literal out of the gate's recognition window, so the gate
went green with the mark deleted — a suppression that was really a blind spot.
Both marks are now load-bearing (removing either reds the gate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…te pin does NOT cover

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@github-actions github-actions Bot added the size/m label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json cf6b67164e37b92cf1fb6b172aa8f44e1fdce666packageMentionDocs.

Which tree this was computed on

This run read content/docs from 370ff4a827a5a3d172fb9f6706b544ae05d53401 — the merge of head ba6dd6e806e5c6ef47e8f7dea87fd5a45efb704c into base cf6b67164e37b92cf1fb6b172aa8f44e1fdce666, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 370ff4a827a5a3d172fb9f6706b544ae05d53401 && git checkout 370ff4a827a5a3d172fb9f6706b544ae05d53401
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 ba6dd6e806e5c6ef47e8f7dea87fd5a45efb704c && git checkout -B drift-repro cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 && git merge --no-ff ba6dd6e806e5c6ef47e8f7dea87fd5a45efb704c

node scripts/docs-audit/affected-docs.mjs --json cf6b67164e37b92cf1fb6b172aa8f44e1fdce666

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT + ruling — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff origin/main...claude/issue-15494-object-graph-null-entry (merge base ed9d87653, head ba6dd6e80): 6 files, +347/−6, matching the PR file list; the claim's ruling (5545357362) taken as written — ONE guard at the seam (object-graph.ts's asArray filters non-records for stack.objects and for obj.fields, which crashed at the identical statement), the skip-not-report decision MEASURED over all 42 authoring rules (28 silent, 13 throwing, 0 reporting the junk entry — every guarding sibling skips), and the probe half: build-probes.ts's catch { findings = [] } becomes a runtime-layer object_field_ref_rule_failed error carrying the thrown message, so a crashed rule can no longer read as a clean object. Reproduction quoted on main (both rules throw at object-graph.ts:159), two ablations each red the new tests by name and restore by blob hash; check:error-code-casing caught the new test's lowercase literals and the exemption marks were placed on the literal's own line after a phantom-mark near-miss was falsified per mark. Tests: lint 95 files / 2946, metadata-protocol probe + publish-drafts suites 10/57 (declared narrowing, CI runs the rest), objectql build-probes.test.ts on a fresh dist; derived family 46/48 green (two NOT MEASURED, unbuilt closure); ratchet gates re-run on the final commit. Changesets @objectstack/lint + @objectstack/metadata-protocol patch. Hot-file scan: no open PR touches object-graph.ts or build-probes.ts.

Ruling on the open question: A — keep Fixes #15494. The card names indexObjectGraph in its title, its measurement and its suggested shape, and all three are delivered; the five further readers the dev inventoried are separate seams this card did not know about, and widening into them would have been the per-member shape the ruling refused. #15552 (the inventory: twelve more rules through five more readers, with validate-widget-bindings.ts:465 unreachable by this seam) is graded on the card as the class's continuation.

Flipping ready and arming auto-merge now.


Generated by Claude Code

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Standing-down note — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

This PR has been ARMED but red since 21:30:35Z: Test Core (6/6) (pull_request run 33920704265, job 101178172265) fails in @objectstack/plugin-auth on ONE test this diff does not touch — src/durability-swallow-repair.test.ts:673 "a refused run-level row is reported, and says the per-row trail survived": Error: Test timed out in 10000ms (Tests 1 failed | 1985 passed, import 482.93s on that runner). The identical failure hit three unrelated PRs (#15554, #15562, #15571) between 21:15Z and 22:01Z while the merge-group runs of the same shard for #15559 / #15532 / #15570 passed. Not this PR's: the diff is packages/lint only. Filed as #15603.

Spending the ONE re-run the dispatch rules allow: rerun_failed_jobs on run 33920704265. If the shard reds again on ba6dd6e80, it is treated as real and comes back to the dev.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

indexObjectGraph throws a TypeError on a null entry in stack.objects — every reference-integrity rule crashes on it

2 participants